HomeRootView.swift 52 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229
  1. import CoreData
  2. import SpriteKit
  3. import SwiftDate
  4. import SwiftUI
  5. import Swinject
  6. struct TimePicker: Identifiable {
  7. var active: Bool
  8. let hours: Int16
  9. var id: String { hours.description }
  10. }
  11. extension Home {
  12. struct RootView: BaseView {
  13. let resolver: Resolver
  14. let safeAreaSize: CGFloat = 0.08
  15. @Environment(\.managedObjectContext) var moc
  16. @Environment(\.colorScheme) var colorScheme
  17. @Environment(AppState.self) var appState
  18. @State var state = StateModel()
  19. @State var settingsPath = NavigationPath()
  20. @State var settingsSearchHighlight = SettingsSearchHighlight()
  21. @State var isStatusPopupPresented = false
  22. @State var showCancelAlert = false
  23. @State var showCancelConfirmDialog = false
  24. @State var isConfirmStopOverrideShown = false
  25. @State var isConfirmStopOverridePresented = false
  26. @State var isConfirmStopTempTargetShown = false
  27. @State var isMenuPresented = false
  28. @State var showTreatments = false
  29. @State var selectedTab: Int = 0
  30. @State var showPumpSelection: Bool = false
  31. @State var showCGMSelection: Bool = false
  32. @State var notificationsDisabled = false
  33. @State var timeButtons: [TimePicker] = [
  34. TimePicker(active: false, hours: 4),
  35. TimePicker(active: false, hours: 6),
  36. TimePicker(active: false, hours: 12),
  37. TimePicker(active: false, hours: 24)
  38. ]
  39. @FetchRequest(fetchRequest: OverrideStored.fetch(
  40. NSPredicate.lastActiveOverride,
  41. ascending: false,
  42. fetchLimit: 1
  43. )) var latestOverride: FetchedResults<OverrideStored>
  44. @FetchRequest(fetchRequest: TempTargetStored.fetch(
  45. NSPredicate.lastActiveTempTarget,
  46. ascending: false,
  47. fetchLimit: 1
  48. )) var latestTempTarget: FetchedResults<TempTargetStored>
  49. var bolusProgressFormatter: NumberFormatter {
  50. let fractionDigits: Int = switch state.settingsManager.preferences.bolusIncrement {
  51. case 0.1: 1
  52. case 0.025: 3
  53. default: 2
  54. }
  55. let formatter = NumberFormatter()
  56. formatter.numberStyle = .decimal
  57. formatter.minimum = 0
  58. formatter.maximumFractionDigits = fractionDigits
  59. formatter.minimumFractionDigits = fractionDigits
  60. formatter.allowsFloats = true
  61. formatter.roundingIncrement = Double(state.settingsManager.preferences.bolusIncrement) as NSNumber
  62. return formatter
  63. }
  64. private var fetchedTargetFormatter: NumberFormatter {
  65. let formatter = NumberFormatter()
  66. formatter.numberStyle = .decimal
  67. if state.units == .mmolL {
  68. formatter.maximumFractionDigits = 1
  69. } else { formatter.maximumFractionDigits = 0 }
  70. return formatter
  71. }
  72. private var historySFSymbol: String {
  73. if #available(iOS 17.0, *) {
  74. return "book.pages"
  75. } else {
  76. return "book"
  77. }
  78. }
  79. @ViewBuilder func pumpTimezoneView(_ badgeImage: UIImage, _ badgeColor: Color) -> some View {
  80. HStack {
  81. Image(uiImage: badgeImage.withRenderingMode(.alwaysTemplate))
  82. .font(.system(size: 14))
  83. .colorMultiply(badgeColor)
  84. Text(String(localized: "Time Change Detected", comment: ""))
  85. .bold()
  86. .font(.system(size: 14))
  87. .foregroundStyle(badgeColor)
  88. }
  89. .onTapGesture {
  90. if state.pumpDisplayState != nil {
  91. // sends user to pump settings
  92. state.shouldDisplayPumpSetupSheet.toggle()
  93. }
  94. }
  95. .frame(maxWidth: .infinity, alignment: .center)
  96. .padding(.vertical, 5)
  97. .padding(.horizontal, 10)
  98. .overlay(
  99. Capsule()
  100. .stroke(badgeColor.opacity(0.4), lineWidth: 2)
  101. )
  102. }
  103. var cgmSelectionButtons: some View {
  104. ForEach(cgmOptions, id: \.name) { option in
  105. if let cgm = state.listOfCGM.first(where: option.predicate) {
  106. Button(option.name) {
  107. state.addCGM(cgm: cgm)
  108. }
  109. }
  110. }
  111. }
  112. var glucoseView: some View {
  113. CurrentGlucoseView(
  114. timerDate: state.timerDate,
  115. units: state.units,
  116. alarm: state.alarm,
  117. lowGlucose: state.lowGlucose,
  118. highGlucose: state.highGlucose,
  119. cgmAvailable: state.cgmAvailable,
  120. currentGlucoseTarget: state.currentGlucoseTarget,
  121. glucoseColorScheme: state.glucoseColorScheme,
  122. glucose: state.latestTwoGlucoseValues
  123. ).scaleEffect(0.9)
  124. .onTapGesture {
  125. if !state.cgmAvailable {
  126. showCGMSelection.toggle()
  127. } else {
  128. state.shouldDisplayCGMSetupSheet.toggle()
  129. }
  130. }
  131. .onLongPressGesture {
  132. let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
  133. impactHeavy.impactOccurred()
  134. state.showModal(for: .snooze)
  135. }
  136. }
  137. var pumpView: some View {
  138. PumpView(
  139. reservoir: state.reservoir,
  140. name: state.pumpName,
  141. expiresAtDate: state.pumpExpiresAtDate,
  142. activatedAtDate: state.pumpActivatedAtDate,
  143. timerDate: state.timerDate,
  144. pumpStatusHighlightMessage: state.pumpStatusHighlightMessage,
  145. battery: state.batteryFromPersistence
  146. )
  147. .onTapGesture {
  148. if state.pumpDisplayState == nil {
  149. // shows user confirmation dialog with pump model choices, then proceeds to setup
  150. showPumpSelection.toggle()
  151. } else {
  152. // sends user to pump settings
  153. state.shouldDisplayPumpSetupSheet.toggle()
  154. }
  155. }
  156. }
  157. var basalString: String? {
  158. var rate: NSNumber = 0
  159. var manualBasalString = ""
  160. guard let apsManager = state.apsManager else {
  161. return nil
  162. }
  163. if apsManager.isScheduledBasal == true {
  164. guard let scheduledRate = scheduledBasalDeliveryRate(at: Date()) else {
  165. return nil
  166. }
  167. rate = scheduledRate
  168. } else {
  169. guard let lastTempBasal = state.tempBasals.last?.tempBasal, let tempRate = lastTempBasal.rate else {
  170. return nil
  171. }
  172. if apsManager.isManualTempBasal {
  173. manualBasalString = String(
  174. localized: " - Manual Basal ⚠️",
  175. comment: "Manual Temp basal"
  176. )
  177. }
  178. rate = tempRate
  179. }
  180. let rateString = Formatter.decimalFormatterWithThreeFractionDigits.string(from: rate) ?? "0"
  181. return rateString + String(localized: " U/hr", comment: "Unit per hour with space") +
  182. manualBasalString
  183. }
  184. // Returns the scheduled basal rate for the current time based on the saved basal scheduled.
  185. // Would be better if in the future BasalDeliveryStatus could be updated to include this info.
  186. func scheduledBasalDeliveryRate(at when: Date) -> NSNumber? {
  187. let calendar = Calendar(identifier: .gregorian)
  188. // calendar.timeZone = timeZone /// should come from pumpManager in case it's different!
  189. let hours = calendar.component(.hour, from: when)
  190. let minutes = calendar.component(.minute, from: when)
  191. let totalMinutes = hours * 60 + minutes
  192. if let rate = findBasalRateForOffset(for: totalMinutes, in: state.basalProfile) {
  193. return NSDecimalNumber(decimal: rate)
  194. }
  195. return nil
  196. }
  197. var overrideString: String? {
  198. guard let latestOverride = latestOverride.first else {
  199. return nil
  200. }
  201. guard let settingsManager = state.settingsManager else {
  202. return nil
  203. }
  204. let percent = latestOverride.percentage
  205. let percentString = percent == 100 ? "" : "\(percent.formatted(.number)) %"
  206. let unit = state.units
  207. var target = (latestOverride.target ?? 0) as Decimal
  208. target = unit == .mmolL ? target.asMmolL : target
  209. var targetString = target == 0 ? "" : (fetchedTargetFormatter.string(from: target as NSNumber) ?? "") + " " + unit
  210. .rawValue
  211. if tempTargetString != nil {
  212. targetString = ""
  213. }
  214. let duration = latestOverride.duration ?? 0
  215. let addedMinutes = Int(truncating: duration)
  216. let date = latestOverride.date ?? Date()
  217. let newDuration = max(
  218. Decimal(Date().distance(to: date.addingTimeInterval(addedMinutes.minutes.timeInterval)).minutes),
  219. 0
  220. )
  221. let indefinite = latestOverride.indefinite
  222. var durationString = ""
  223. if !indefinite {
  224. if newDuration >= 1 {
  225. durationString = formatHrMin(Int(newDuration))
  226. } else if newDuration > 0 {
  227. durationString = "\(Int(newDuration * 60)) s"
  228. } else {
  229. /// Do not show the Override anymore
  230. Task {
  231. guard let objectID = self.latestOverride.first?.objectID else { return }
  232. await state.cancelOverride(withID: objectID)
  233. }
  234. }
  235. }
  236. let smbScheduleString = latestOverride
  237. .smbIsScheduledOff && ((latestOverride.start?.stringValue ?? "") != (latestOverride.end?.stringValue ?? ""))
  238. ? " \(formatTimeRange(start: latestOverride.start?.stringValue, end: latestOverride.end?.stringValue))"
  239. : ""
  240. let smbToggleString = latestOverride.smbIsOff || latestOverride
  241. .smbIsScheduledOff ? String(localized: "SMBs Off\(smbScheduleString)") : ""
  242. var smbMinuteString: String = ""
  243. var uamMinuteString: String = ""
  244. if !latestOverride.smbIsOff, latestOverride.advancedSettings {
  245. if let smbMinutes = latestOverride.smbMinutes,
  246. smbMinutes.decimalValue != settingsManager.preferences.maxSMBBasalMinutes
  247. {
  248. smbMinuteString = "SMB\u{00A0}\(smbMinutes)\u{00A0}" +
  249. String(localized: "m", comment: "Abbreviation for Minutes")
  250. }
  251. if let uamMinutes = latestOverride.uamMinutes,
  252. uamMinutes.decimalValue != settingsManager.preferences.maxUAMSMBBasalMinutes
  253. {
  254. uamMinuteString = "UAM\u{00A0}\(uamMinutes)\u{00A0}" +
  255. String(localized: "m", comment: "Abbreviation for Minutes")
  256. }
  257. }
  258. let components = [durationString, percentString, targetString, smbToggleString, smbMinuteString, uamMinuteString]
  259. .filter { !$0.isEmpty }
  260. return components.isEmpty ? nil : components.joined(separator: ", ")
  261. }
  262. var tempTargetString: String? {
  263. guard let latestTempTarget = latestTempTarget.first else {
  264. return nil
  265. }
  266. let duration = latestTempTarget.duration
  267. let addedMinutes = Int(truncating: duration ?? 0)
  268. let date = latestTempTarget.date ?? Date()
  269. let newDuration = max(
  270. Decimal(Date().distance(to: date.addingTimeInterval(addedMinutes.minutes.timeInterval)).minutes),
  271. 0
  272. )
  273. var durationString = ""
  274. var percentageString = ""
  275. var target = (latestTempTarget.target ?? 100) as Decimal
  276. // Use TempTargetCalculations to get effective HBT (handles both custom and auto-adjusted standard TT)
  277. let effectiveHBT = TempTargetCalculations.computeEffectiveHBT(
  278. tempTargetHalfBasalTarget: latestTempTarget.halfBasalTarget?.decimalValue,
  279. settingHalfBasalTarget: state.settingHalfBasalTarget,
  280. target: target,
  281. autosensMax: state.autosensMax
  282. ) ?? state.settingHalfBasalTarget
  283. var showPercentage = false
  284. if target > 100, state.isExerciseModeActive || state.highTTraisesSens { showPercentage = true }
  285. if target < 100, state.lowTTlowersSens, state.autosensMax > 1 { showPercentage = true }
  286. if showPercentage {
  287. percentageString =
  288. " \(Int(TempTargetCalculations.computeAdjustedPercentage(halfBasalTarget: effectiveHBT, target: target, autosensMax: state.autosensMax)))%"
  289. }
  290. target = state.units == .mmolL ? target.asMmolL : target
  291. let targetString = target == 0 ? "" : (fetchedTargetFormatter.string(from: target as NSNumber) ?? "") + " " +
  292. state.units.rawValue + percentageString
  293. if newDuration >= 1 {
  294. durationString =
  295. "\(newDuration.formatted(.number.grouping(.never).rounded().precision(.fractionLength(0)))) min"
  296. } else if newDuration > 0 {
  297. durationString =
  298. "\((newDuration * 60).formatted(.number.grouping(.never).rounded().precision(.fractionLength(0)))) s"
  299. } else {
  300. /// Do not show the Temp Target anymore
  301. Task {
  302. guard let objectID = self.latestTempTarget.first?.objectID else { return }
  303. await state.cancelTempTarget(withID: objectID)
  304. }
  305. }
  306. let components = [targetString, durationString].filter { !$0.isEmpty }
  307. return components.isEmpty ? nil : components.joined(separator: ", ")
  308. }
  309. var timeIntervalButtons: some View {
  310. let buttonColor = (colorScheme == .dark ? Color.white : Color.black).opacity(0.8)
  311. return HStack(alignment: .center) {
  312. ForEach(timeButtons) { button in
  313. Button(action: {
  314. state.hours = button.hours
  315. }) {
  316. Group {
  317. if button.active {
  318. Text(
  319. button.hours.description + "\u{00A0}" +
  320. String(localized: "h", comment: "h")
  321. )
  322. } else {
  323. Text(button.hours.description)
  324. }
  325. }
  326. .font(.footnote)
  327. .fontWeight(button.active ? .semibold : .regular)
  328. .padding(.vertical, 5)
  329. .padding(.horizontal, 10)
  330. .foregroundColor(
  331. button
  332. .active ? (colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white) : buttonColor
  333. )
  334. .background(button.active ? buttonColor.opacity(colorScheme == .dark ? 1 : 0.8) : Color.clear)
  335. .clipShape(Capsule())
  336. .overlay(
  337. Capsule()
  338. .stroke(button.active ? buttonColor.opacity(0.4) : Color.clear, lineWidth: 2)
  339. )
  340. }
  341. }
  342. }
  343. }
  344. var statsIconString: String {
  345. if #available(iOS 18, *) {
  346. return "chart.line.text.clipboard"
  347. } else {
  348. return "list.clipboard"
  349. }
  350. }
  351. @ViewBuilder private func tappableButton(
  352. buttonColor: Color,
  353. label: String,
  354. iconString: String,
  355. action: @escaping () -> Void
  356. ) -> some View {
  357. Button(action: {
  358. action()
  359. }) {
  360. HStack {
  361. Image(systemName: iconString)
  362. Text(label)
  363. }
  364. .font(.footnote)
  365. .padding(.vertical, 5)
  366. .padding(.horizontal, 10)
  367. .foregroundStyle(buttonColor)
  368. .overlay(
  369. Capsule()
  370. .stroke(buttonColor.opacity(0.4), lineWidth: 2)
  371. )
  372. }
  373. }
  374. @ViewBuilder func mainChart(geo: GeometryProxy) -> some View {
  375. ZStack {
  376. MainChartView(
  377. geo: geo,
  378. safeAreaSize: notificationsDisabled == true ? safeAreaSize : 0,
  379. units: state.units,
  380. hours: state.filteredHours,
  381. highGlucose: state.highGlucose,
  382. lowGlucose: state.lowGlucose,
  383. currentGlucoseTarget: state.currentGlucoseTarget,
  384. glucoseColorScheme: state.glucoseColorScheme,
  385. screenHours: state.hours,
  386. displayXgridLines: state.displayXgridLines,
  387. displayYgridLines: state.displayYgridLines,
  388. thresholdLines: state.thresholdLines,
  389. state: state
  390. )
  391. }
  392. .padding(.bottom, UIDevice.adjustPadding(min: 0, max: nil))
  393. }
  394. func highlightButtons() {
  395. for i in 0 ..< timeButtons.count {
  396. timeButtons[i].active = timeButtons[i].hours == state.hours
  397. }
  398. }
  399. @ViewBuilder func rightHeaderPanel(_: GeometryProxy) -> some View {
  400. VStack(alignment: .leading, spacing: 20) {
  401. /// Loop view at bottomLeading
  402. LoopView(
  403. closedLoop: state.closedLoop,
  404. timerDate: state.timerDate,
  405. isLooping: state.isLooping,
  406. lastLoopDate: state.lastLoopDate,
  407. manualTempBasal: state.manualTempBasal,
  408. determination: state.determinationsFromPersistence
  409. )
  410. .onTapGesture {
  411. state.isLoopStatusPresented = true
  412. }
  413. .onLongPressGesture {
  414. let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
  415. impactHeavy.impactOccurred()
  416. state.runLoop()
  417. }
  418. /// eventualBG string at bottomTrailing
  419. if let eventualBG = state.enactedAndNonEnactedDeterminations.first?.eventualBG {
  420. let eventualGlucose = eventualBG as Decimal
  421. HStack {
  422. Image(systemName: "arrow.right.circle")
  423. .font(.callout)
  424. .fontWeight(.bold)
  425. Text(state.units == .mgdL ? eventualGlucose.description : eventualGlucose.formattedAsMmolL)
  426. .font(.callout)
  427. .fontWeight(.bold)
  428. .fontDesign(.rounded)
  429. }
  430. // aligns the evBG icon exactly with the first pixel of loop status icon
  431. .padding(.leading, 12)
  432. } else {
  433. HStack {
  434. Image(systemName: "arrow.right.circle")
  435. .font(.callout).fontWeight(.bold)
  436. Text("--")
  437. .font(.callout).fontWeight(.bold).fontDesign(.rounded)
  438. }
  439. }
  440. }
  441. }
  442. @ViewBuilder func mealPanel(_: GeometryProxy) -> some View {
  443. HStack {
  444. HStack {
  445. Image(systemName: "syringe.fill")
  446. .font(.callout)
  447. .foregroundColor(Color.insulin)
  448. Text(
  449. (
  450. Formatter.decimalFormatterWithTwoFractionDigits
  451. .string(from: state.currentIOB as NSNumber) ?? "0"
  452. ) +
  453. String(localized: " U", comment: "Insulin unit")
  454. )
  455. .font(.callout).fontWeight(.bold).fontDesign(.rounded)
  456. }
  457. Spacer()
  458. HStack {
  459. Image(systemName: "fork.knife")
  460. .font(.callout)
  461. .foregroundColor(.loopYellow)
  462. Text(
  463. (
  464. Formatter.decimalFormatterWithTwoFractionDigits.string(
  465. from: NSNumber(value: state.enactedAndNonEnactedDeterminations.first?.cob ?? 0)
  466. ) ?? "0"
  467. ) +
  468. String(localized: " g", comment: "gram of carbs")
  469. )
  470. .font(.callout).fontWeight(.bold).fontDesign(.rounded)
  471. }
  472. Spacer()
  473. if state.maxIOB == 0.0 {
  474. HStack {
  475. Image(systemName: "exclamationmark.circle.fill")
  476. Text("MaxIOB: 0 U")
  477. }.bold()
  478. .foregroundStyle(Color.red)
  479. .font(.callout)
  480. } else {
  481. HStack {
  482. /// Only display the insulin delivery rate info if the pump is not
  483. /// suspended and is available (e.g., pod is paired & not faulted).
  484. let pumpAvailable = state.apsManager.isScheduledBasal != nil
  485. if !state.apsManager.isSuspended && pumpAvailable {
  486. Image(systemName: "drop.circle")
  487. .font(.callout)
  488. .foregroundColor(.insulinTintColor)
  489. if let basalString = self.basalString {
  490. /// Adjust opacity when displaying a scheduled basal rate
  491. let opacity = state.apsManager?.isScheduledBasal == true ? 0.6 : 1.0
  492. if basalString.count > 5 {
  493. Text(basalString)
  494. .font(.callout).fontWeight(.bold).fontDesign(.rounded)
  495. .lineLimit(1)
  496. .minimumScaleFactor(0.85)
  497. .truncationMode(.tail)
  498. .allowsTightening(true)
  499. .opacity(opacity)
  500. } else {
  501. // Short strings can just display normally
  502. Text(basalString)
  503. .font(.callout).fontWeight(.bold).fontDesign(.rounded)
  504. .opacity(opacity)
  505. }
  506. } else {
  507. Text("No Data")
  508. .font(.callout).fontWeight(.bold).fontDesign(.rounded)
  509. }
  510. }
  511. }
  512. }
  513. }.padding(.horizontal)
  514. }
  515. @ViewBuilder func adjustmentsOverrideView(_ overrideString: String) -> some View {
  516. Group {
  517. Image(systemName: "clock.arrow.2.circlepath")
  518. .font(.title2)
  519. .foregroundStyle(Color.primary, Color.purple)
  520. VStack(alignment: .leading) {
  521. Text(latestOverride.first?.name ?? String(localized: "Custom Override"))
  522. .font(.subheadline)
  523. .frame(alignment: .leading)
  524. Text(overrideString)
  525. .font(.caption)
  526. }
  527. }
  528. .onTapGesture {
  529. selectedTab = 2
  530. }
  531. }
  532. @ViewBuilder func adjustmentsTempTargetView(_ tempTargetString: String) -> some View {
  533. Group {
  534. Image(systemName: "target")
  535. .font(.title2)
  536. .foregroundStyle(Color.loopGreen)
  537. VStack(alignment: .leading) {
  538. Text(latestTempTarget.first?.name ?? String(localized: "Temp Target"))
  539. .font(.subheadline)
  540. Text(tempTargetString)
  541. .font(.caption)
  542. }
  543. }
  544. .onTapGesture {
  545. selectedTab = 2
  546. }
  547. }
  548. @ViewBuilder func adjustmentsCancelView(_ cancelAction: @escaping () -> Void) -> some View {
  549. Image(systemName: "xmark.app")
  550. .font(.title)
  551. .onTapGesture {
  552. cancelAction()
  553. }
  554. }
  555. @ViewBuilder func adjustmentsCancelTempTargetView() -> some View {
  556. Image(systemName: "xmark.app")
  557. .font(.title)
  558. .confirmationDialog(
  559. "Stop the Temp Target \"\(latestTempTarget.first?.name ?? "")\"?",
  560. isPresented: $isConfirmStopTempTargetShown,
  561. titleVisibility: .visible
  562. ) {
  563. Button("Stop", role: .destructive) {
  564. Task {
  565. guard let objectID = latestTempTarget.first?.objectID else { return }
  566. await state.cancelTempTarget(withID: objectID)
  567. }
  568. }
  569. Button("Cancel", role: .cancel) {}
  570. }
  571. .padding(.trailing, 8)
  572. .onTapGesture {
  573. if !latestTempTarget.isEmpty {
  574. isConfirmStopTempTargetShown = true
  575. }
  576. }
  577. }
  578. @ViewBuilder func adjustmentsCancelOverrideView() -> some View {
  579. Image(systemName: "xmark.app")
  580. .font(.title)
  581. .confirmationDialog(
  582. "Stop the Override \"\(latestOverride.first?.name ?? "")\"?",
  583. isPresented: $isConfirmStopOverridePresented,
  584. titleVisibility: .visible
  585. ) {
  586. Button("Stop", role: .destructive) {
  587. Task {
  588. guard let objectID = latestOverride.first?.objectID else { return }
  589. await state.cancelOverride(withID: objectID)
  590. }
  591. }
  592. Button("Cancel", role: .cancel) {}
  593. }
  594. .padding(.trailing, 8)
  595. .onTapGesture {
  596. if !latestOverride.isEmpty {
  597. isConfirmStopOverridePresented = true
  598. }
  599. }
  600. }
  601. @ViewBuilder func noActiveAdjustmentsView() -> some View {
  602. Group {
  603. VStack {
  604. Text("No Active Adjustment")
  605. .font(.subheadline)
  606. .frame(maxWidth: .infinity, alignment: .leading)
  607. Text("Profile at 100 %")
  608. .font(.caption)
  609. .frame(maxWidth: .infinity, alignment: .leading)
  610. }.padding(.leading, 10)
  611. Spacer()
  612. /// to ensure the same position....
  613. Image(systemName: "xmark.app")
  614. .font(.title)
  615. // clear color for the icon
  616. .foregroundStyle(Color.clear)
  617. }.onTapGesture {
  618. selectedTab = 2
  619. }
  620. }
  621. @ViewBuilder func adjustmentView(geo: GeometryProxy) -> some View {
  622. // let background = colorScheme == .dark ? Material.ultraThinMaterial.opacity(0.5) : Color.black.opacity(0.2)
  623. ZStack {
  624. /// rectangle as background
  625. RoundedRectangle(cornerRadius: 15)
  626. .fill(
  627. (overrideString != nil || tempTargetString != nil) ?
  628. (
  629. colorScheme == .dark ?
  630. Color(red: 0.03921568627, green: 0.133333333, blue: 0.2156862745) :
  631. Color.insulin.opacity(0.1)
  632. ) : Color.clear // Use clear and add the Material in the background
  633. )
  634. .background(colorScheme == .dark ? Color.chart.opacity(0.25) : Color.black.opacity(0.075))
  635. .clipShape(RoundedRectangle(cornerRadius: 15))
  636. .frame(height: geo.size.height * 0.08)
  637. .shadow(
  638. color: (overrideString != nil || tempTargetString != nil) ?
  639. (
  640. colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) :
  641. Color.black.opacity(0.33)
  642. ) : Color.clear,
  643. radius: 3
  644. )
  645. HStack {
  646. if let overrideString = overrideString, let tempTargetString = tempTargetString {
  647. HStack {
  648. adjustmentsOverrideView(overrideString)
  649. Spacer()
  650. Divider()
  651. .frame(height: geo.size.height * 0.05)
  652. .padding(.horizontal, 2)
  653. adjustmentsTempTargetView(tempTargetString)
  654. Spacer()
  655. adjustmentsCancelView({
  656. if !latestTempTarget.isEmpty, !latestOverride.isEmpty {
  657. showCancelConfirmDialog = true
  658. } else if !latestOverride.isEmpty {
  659. showCancelAlert = true
  660. } else if !latestTempTarget.isEmpty {
  661. showCancelAlert = true
  662. }
  663. })
  664. }
  665. } else if let overrideString = overrideString {
  666. adjustmentsOverrideView(overrideString)
  667. Spacer()
  668. adjustmentsCancelOverrideView()
  669. } else if let tempTargetString = tempTargetString {
  670. HStack {
  671. adjustmentsTempTargetView(tempTargetString)
  672. Spacer()
  673. adjustmentsCancelTempTargetView()
  674. }
  675. } else {
  676. noActiveAdjustmentsView()
  677. }
  678. }.padding(.horizontal, 10)
  679. .confirmationDialog("Adjustment to Stop", isPresented: $showCancelConfirmDialog) {
  680. Button("Stop Override", role: .destructive) {
  681. Task {
  682. guard let objectID = latestOverride.first?.objectID else { return }
  683. await state.cancelOverride(withID: objectID)
  684. }
  685. }
  686. Button("Stop Temp Target", role: .destructive) {
  687. Task {
  688. guard let objectID = latestTempTarget.first?.objectID else { return }
  689. await state.cancelTempTarget(withID: objectID)
  690. }
  691. }
  692. Button("Stop All Adjustments", role: .destructive) {
  693. Task {
  694. guard let overrideObjectID = latestOverride.first?.objectID else { return }
  695. await state.cancelOverride(withID: overrideObjectID)
  696. guard let tempTargetObjectID = latestTempTarget.first?.objectID else { return }
  697. await state.cancelTempTarget(withID: tempTargetObjectID)
  698. }
  699. }
  700. } message: {
  701. Text("Select Adjustment")
  702. }
  703. }.padding(.horizontal, 10).padding(.bottom, UIDevice.adjustPadding(min: nil, max: 10))
  704. }
  705. @ViewBuilder func bolusView(geo: GeometryProxy, _ progress: Decimal) -> some View {
  706. /// ensure that state.lastPumpBolus has a value, i.e. there is a last bolus done by the pump and not an external bolus
  707. /// - TRUE: show the pump bolus
  708. /// - FALSE: do not show a progress bar at all
  709. if let bolusTotal = state.lastPumpBolus?.bolus?.amount {
  710. let bolusFraction = progress * (bolusTotal as Decimal)
  711. let bolusString =
  712. (bolusProgressFormatter.string(from: bolusFraction as NSNumber) ?? "0")
  713. + String(localized: " of ", comment: "Bolus string partial message: 'x U of y U' in home view") +
  714. (Formatter.decimalFormatterWithThreeFractionDigits.string(from: bolusTotal as NSNumber) ?? "0")
  715. + String(localized: " U", comment: "Insulin unit")
  716. ZStack {
  717. /// rectangle as background
  718. RoundedRectangle(cornerRadius: 15)
  719. .fill(
  720. colorScheme == .dark ? Color(red: 0.03921568627, green: 0.133333333, blue: 0.2156862745) : Color
  721. .insulin
  722. .opacity(0.2)
  723. )
  724. .clipShape(RoundedRectangle(cornerRadius: 15))
  725. .frame(height: geo.size.height * 0.08)
  726. .shadow(
  727. color: colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) :
  728. Color.black.opacity(0.33),
  729. radius: 3
  730. )
  731. /// actual bolus view
  732. HStack {
  733. Image(systemName: "cross.vial.fill")
  734. .font(.system(size: 25))
  735. Spacer()
  736. VStack {
  737. Text("Bolusing")
  738. .font(.subheadline)
  739. .frame(maxWidth: .infinity, alignment: .leading)
  740. Text(bolusString)
  741. .font(.caption)
  742. .frame(maxWidth: .infinity, alignment: .leading)
  743. }.padding(.leading, 5)
  744. Spacer()
  745. Button {
  746. state.showProgressView()
  747. state.cancelBolus()
  748. } label: {
  749. Image(systemName: "xmark.app")
  750. .font(.system(size: 25))
  751. }
  752. }.padding(.horizontal, 10)
  753. .padding(.trailing, 8)
  754. }
  755. .padding(.horizontal, 10)
  756. .padding(.bottom, UIDevice.adjustPadding(min: nil, max: 10))
  757. .overlay(alignment: .bottom) {
  758. BolusProgressBar(progress: progress)
  759. .padding(.horizontal, 18)
  760. .padding(.bottom, 9)
  761. }.clipShape(RoundedRectangle(cornerRadius: 15))
  762. }
  763. }
  764. @ViewBuilder func alertSafetyNotificationsView(geo: GeometryProxy) -> some View {
  765. ZStack {
  766. /// rectangle as background
  767. RoundedRectangle(cornerRadius: 15)
  768. .fill(
  769. Color(
  770. red: 0.9,
  771. green: 0.133333333,
  772. blue: 0.2156862745
  773. )
  774. )
  775. .clipShape(RoundedRectangle(cornerRadius: 15))
  776. .frame(height: geo.size.height * safeAreaSize)
  777. .coordinateSpace(name: "alertSafetyNotificationsView")
  778. .shadow(
  779. color: colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) :
  780. Color.black.opacity(0.33),
  781. radius: 3
  782. )
  783. HStack {
  784. Spacer()
  785. VStack {
  786. Text("⚠️ Safety Notifications are OFF")
  787. .font(.headline)
  788. .fontWeight(.bold)
  789. .fontDesign(.rounded)
  790. .foregroundStyle(.white.gradient)
  791. .frame(maxWidth: .infinity, alignment: .leading)
  792. Text("Fix now by turning Notifications ON.")
  793. .font(.footnote)
  794. .fontDesign(.rounded)
  795. .foregroundStyle(.white.gradient)
  796. .frame(maxWidth: .infinity, alignment: .leading)
  797. }.padding(.leading, 5)
  798. Spacer()
  799. Image(systemName: "chevron.right").foregroundColor(.white)
  800. .font(.headline)
  801. }.padding(.horizontal, 10)
  802. .padding(.trailing, 8)
  803. .onTapGesture {
  804. UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
  805. }
  806. }.padding(.horizontal, 10)
  807. .padding(.top, 0)
  808. }
  809. @ViewBuilder func mainViewElements(_ geo: GeometryProxy) -> some View {
  810. VStack(spacing: 0) {
  811. ZStack {
  812. if let apsManager = state.apsManager, let bluetoothManager = apsManager.bluetoothManager,
  813. bluetoothManager.bluetoothAuthorization != .authorized
  814. {
  815. BluetoothRequiredView()
  816. } else {
  817. /// right panel with loop status and evBG
  818. HStack {
  819. Spacer()
  820. rightHeaderPanel(geo)
  821. }.padding(.trailing, 20)
  822. /// glucose bobble
  823. glucoseView
  824. /// left panel with pump related info
  825. HStack {
  826. pumpView
  827. Spacer()
  828. }.padding(.leading, 20)
  829. }
  830. }
  831. .padding(.top, 10)
  832. .safeAreaInset(edge: .top, spacing: 0) {
  833. if notificationsDisabled {
  834. alertSafetyNotificationsView(geo: geo)
  835. }
  836. if let badgeImage = state.pumpStatusBadgeImage, let badgeColor = state.pumpStatusBadgeColor {
  837. pumpTimezoneView(badgeImage, badgeColor)
  838. .padding(.horizontal, 20)
  839. }
  840. }
  841. mealPanel(geo).padding(.top, UIDevice.adjustPadding(min: nil, max: 30))
  842. .padding(.bottom, UIDevice.adjustPadding(min: nil, max: 20))
  843. mainChart(geo: geo)
  844. HStack {
  845. tappableButton(
  846. buttonColor: (colorScheme == .dark ? Color.white : Color.black).opacity(0.8),
  847. label: String(localized: "Stats", comment: "Stats icon in main view"),
  848. iconString: statsIconString,
  849. action: { state.showModal(for: .statistics) }
  850. )
  851. Spacer()
  852. timeIntervalButtons.padding(.top, UIDevice.adjustPadding(min: 0, max: 10))
  853. .padding(.bottom, UIDevice.adjustPadding(min: 0, max: 10))
  854. Spacer()
  855. tappableButton(
  856. buttonColor: (colorScheme == .dark ? Color.white : Color.black).opacity(0.8),
  857. label: String(localized: "Info", comment: "Info icon in main view"),
  858. iconString: "info",
  859. action: { state.isLegendPresented.toggle() }
  860. )
  861. }.padding([.horizontal, .bottom])
  862. if let progress = state.bolusProgress {
  863. bolusView(geo: geo, progress)
  864. .padding(.bottom, UIDevice.adjustPadding(min: nil, max: 40))
  865. } else {
  866. adjustmentView(geo: geo).padding(.bottom, UIDevice.adjustPadding(min: nil, max: 40))
  867. }
  868. }
  869. .background(appState.trioBackgroundColor(for: colorScheme))
  870. .onReceive(
  871. resolver.resolve(AlertPermissionsChecker.self)!.$notificationsDisabled,
  872. perform: {
  873. if notificationsDisabled != $0 {
  874. notificationsDisabled = $0
  875. if notificationsDisabled {
  876. debug(.default, "notificationsDisabled")
  877. }
  878. }
  879. }
  880. )
  881. }
  882. @ViewBuilder func mainView() -> some View {
  883. GeometryReader { geo in
  884. mainViewElements(geo)
  885. }
  886. .onChange(of: state.hours) {
  887. highlightButtons()
  888. }
  889. .onAppear {
  890. configureView {
  891. highlightButtons()
  892. }
  893. }
  894. .navigationTitle("Home")
  895. .navigationBarHidden(true)
  896. .blur(radius: state.isLoopStatusPresented ? 3 : 0)
  897. .sheet(isPresented: $state.isLoopStatusPresented) {
  898. LoopStatusView(state: state)
  899. }
  900. .sheet(isPresented: $state.isLegendPresented) {
  901. ChartLegendView(state: state)
  902. }
  903. // PUMP RELATED
  904. .confirmationDialog("Pump Model", isPresented: $showPumpSelection) {
  905. Button("Medtronic") { state.addPump(.minimed) }
  906. Button("Omnipod") { state.addPump(.omni) }
  907. Button("Dana(RS/-i)") { state.addPump(.dana) }
  908. Button("Medtrum Nano") { state.addPump(.medtrum) }
  909. Button("Pump Simulator") { state.addPump(.simulator) }
  910. } message: { Text("Select Pump Model") }
  911. .sheet(isPresented: $state.shouldDisplayPumpSetupSheet) {
  912. if let pumpManager = state.provider.apsManager.pumpManager {
  913. PumpConfig.PumpSettingsView(
  914. pumpManager: pumpManager,
  915. bluetoothManager: state.provider.apsManager.bluetoothManager!,
  916. completionDelegate: state,
  917. setupDelegate: state
  918. )
  919. } else {
  920. PumpConfig.PumpSetupView(
  921. pumpType: state.setupPumpType,
  922. pumpInitialSettings: state.pumpInitialSettings,
  923. bluetoothManager: state.provider.apsManager.bluetoothManager!,
  924. completionDelegate: state,
  925. setupDelegate: state
  926. )
  927. }
  928. }
  929. // CGM RELATED
  930. .confirmationDialog("CGM Model", isPresented: $showCGMSelection) {
  931. cgmSelectionButtons
  932. } message: {
  933. Text("Select CGM Model")
  934. }
  935. .sheet(isPresented: $state.shouldDisplayCGMSetupSheet) {
  936. switch state.cgmCurrent.type {
  937. case .enlite,
  938. .nightscout,
  939. .none,
  940. .simulator,
  941. .xdrip:
  942. CGMSettings.CustomCGMOptionsView(
  943. resolver: self.resolver,
  944. state: state.cgmStateModel,
  945. cgmCurrent: state.cgmCurrent,
  946. deleteCGM: state.deleteCGM
  947. )
  948. case .plugin:
  949. if let fetchGlucoseManager = state.fetchGlucoseManager,
  950. let cgmManager = fetchGlucoseManager.cgmManager,
  951. state.cgmCurrent.type == fetchGlucoseManager.cgmGlucoseSourceType,
  952. state.cgmCurrent.id == fetchGlucoseManager.cgmGlucosePluginId
  953. {
  954. CGMSettings.CGMSettingsView(
  955. cgmManager: cgmManager,
  956. bluetoothManager: state.provider.apsManager.bluetoothManager!,
  957. unit: state.settingsManager.settings.units,
  958. completionDelegate: state
  959. )
  960. } else {
  961. CGMSettings.CGMSetupView(
  962. CGMType: state.cgmCurrent,
  963. bluetoothManager: state.provider.apsManager.bluetoothManager!,
  964. unit: state.settingsManager.settings.units,
  965. completionDelegate: state,
  966. setupDelegate: state,
  967. pluginCGMManager: self.state.pluginCGMManager
  968. )
  969. }
  970. }
  971. }
  972. }
  973. @ViewBuilder func tabBar() -> some View {
  974. ZStack(alignment: .bottom) {
  975. TabView(selection: $selectedTab) {
  976. let carbsRequiredBadge: String? = {
  977. guard let carbsRequired = state.enactedAndNonEnactedDeterminations.first?.carbsRequired,
  978. state.showCarbsRequiredBadge
  979. else {
  980. return nil
  981. }
  982. let carbsRequiredDecimal = Decimal(carbsRequired)
  983. if carbsRequiredDecimal > state.settingsManager.settings.carbsRequiredThreshold {
  984. let numberAsNSNumber = NSDecimalNumber(decimal: carbsRequiredDecimal)
  985. return (Formatter.decimalFormatterWithTwoFractionDigits.string(from: numberAsNSNumber) ?? "") + " g"
  986. }
  987. return nil
  988. }()
  989. NavigationStack { mainView() }
  990. .tabItem { Label("Main", systemImage: "chart.xyaxis.line") }
  991. .badge(carbsRequiredBadge).tag(0)
  992. NavigationStack { History.RootView(resolver: resolver) }
  993. .tabItem { Label("History", systemImage: historySFSymbol) }.tag(1)
  994. Spacer()
  995. NavigationStack { Adjustments.RootView(resolver: resolver) }
  996. .tabItem {
  997. Label(
  998. "Adjustments",
  999. systemImage: "slider.horizontal.2.gobackward"
  1000. ) }.tag(2)
  1001. NavigationStack(path: self.$settingsPath) {
  1002. Settings.RootView(resolver: resolver) }
  1003. .environment(settingsSearchHighlight)
  1004. .tabItem { Label(
  1005. "Settings",
  1006. systemImage: "gear"
  1007. ) }.tag(3)
  1008. }
  1009. .tint(Color.tabBar)
  1010. Button(
  1011. action: {
  1012. state.showModal(for: .treatmentView) },
  1013. label: {
  1014. Image(systemName: "plus.circle.fill")
  1015. .font(.system(size: 40))
  1016. .foregroundStyle(Color.tabBar)
  1017. .padding(.vertical, 2)
  1018. .padding(.horizontal, 24)
  1019. }
  1020. )
  1021. }.ignoresSafeArea(.keyboard, edges: .bottom).blur(radius: state.waitForSuggestion ? 8 : 0)
  1022. .onChange(of: selectedTab) {
  1023. if !settingsPath.isEmpty {
  1024. settingsPath = NavigationPath()
  1025. }
  1026. }
  1027. }
  1028. var body: some View {
  1029. ZStack(alignment: .center) {
  1030. tabBar()
  1031. if state.waitForSuggestion {
  1032. CustomProgressView(text: String(localized: "Updating IOB...", comment: "Progress text when updating IOB"))
  1033. }
  1034. }
  1035. }
  1036. }
  1037. }
  1038. extension UIDevice {
  1039. public enum DeviceSize: CGFloat {
  1040. case smallDevice = 667 // Height for 4" iPhone SE
  1041. case largeDevice = 852 // Height for 6.1" iPhone 15 Pro
  1042. }
  1043. @usableFromInline static func adjustPadding(
  1044. min: CGFloat? = nil,
  1045. max: CGFloat? = nil
  1046. ) -> CGFloat? {
  1047. if UIScreen.screenHeight > UIDevice.DeviceSize.smallDevice.rawValue {
  1048. if UIScreen.screenHeight >= UIDevice.DeviceSize.largeDevice.rawValue {
  1049. return max
  1050. } else {
  1051. return min != nil ?
  1052. (max != nil ? max! * (UIScreen.screenHeight / UIDevice.DeviceSize.largeDevice.rawValue) : nil) : nil
  1053. }
  1054. } else {
  1055. return min
  1056. }
  1057. }
  1058. }
  1059. extension UIScreen {
  1060. static var screenHeight: CGFloat {
  1061. UIScreen.main.bounds.height
  1062. }
  1063. static var screenWidth: CGFloat {
  1064. UIScreen.main.bounds.width
  1065. }
  1066. }
  1067. /// Checks if the device is using a 24-hour time format.
  1068. func is24HourFormat() -> Bool {
  1069. let formatter = DateFormatter()
  1070. formatter.locale = Locale.current
  1071. formatter.dateStyle = .none
  1072. formatter.timeStyle = .short
  1073. let dateString = formatter.string(from: Date())
  1074. return !dateString.contains("AM") && !dateString.contains("PM")
  1075. }
  1076. /// Converts a duration in minutes to a formatted string (e.g., "1 h 30 m").
  1077. func formatHrMin(_ durationInMinutes: Int) -> String {
  1078. let hours = durationInMinutes / 60
  1079. let minutes = durationInMinutes % 60
  1080. switch (hours, minutes) {
  1081. case let (0, m):
  1082. return "\(m)\u{00A0}" + String(localized: "m", comment: "Abbreviation for Minutes")
  1083. case let (h, 0):
  1084. return "\(h)\u{00A0}" + String(localized: "h", comment: "h")
  1085. default:
  1086. return hours.description + "\u{00A0}" + String(localized: "h", comment: "h") + "\u{00A0}" + minutes
  1087. .description + "\u{00A0}" + String(localized: "m", comment: "Abbreviation for Minutes")
  1088. }
  1089. }
  1090. // Helper function to convert a start and end hour to either 24-hour or AM/PM format
  1091. func formatTimeRange(start: String?, end: String?) -> String {
  1092. guard let start = start, let end = end else {
  1093. return ""
  1094. }
  1095. // Check if the format is 24-hour or AM/PM
  1096. if is24HourFormat() {
  1097. // Return the original 24-hour format
  1098. return "\(start)-\(end)"
  1099. } else {
  1100. // Convert to AM/PM format using DateFormatter
  1101. let formatter = DateFormatter()
  1102. formatter.dateFormat = "HH"
  1103. if let startHour = Int(start), let endHour = Int(end) {
  1104. let startDate = Calendar.current.date(bySettingHour: startHour, minute: 0, second: 0, of: Date()) ?? Date()
  1105. let endDate = Calendar.current.date(bySettingHour: endHour, minute: 0, second: 0, of: Date()) ?? Date()
  1106. // Customize the format to "2p" or "2a"
  1107. formatter.dateFormat = "ha"
  1108. let startFormatted = formatter.string(from: startDate).lowercased().replacingOccurrences(of: "m", with: "")
  1109. let endFormatted = formatter.string(from: endDate).lowercased().replacingOccurrences(of: "m", with: "")
  1110. return "\(startFormatted)-\(endFormatted)"
  1111. } else {
  1112. return ""
  1113. }
  1114. }
  1115. }